跳到主要内容

Java 并发编程-Lock 接口及其相关工具类

Lock 接口

为什么需要 Lock ?

synchronized 是Java 语言层面的,是内置的关键字;Lock 则是 JDK 5 的 J.U.C(java/util/concurrent)包中出现的一个类,在使用时,synchronized 同步的代码块可以由 JVM 自动释放;Lock 需要程序员在 finally 块中手工释放;synchronized 是比较古老的实现机制,设计较早,有一些功能上的限制(其实 JDK 内部对 synchronized 进行了一系列优化,具体可以看 synchronized 的四种状态):

  • 它无法中断一个正在等候获得锁的线程
  • 也无法通过投票得到锁,如果不想等下去,也就没法得到锁。
  • 同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行

而且对多线程环境中,使用 synchronized 后,线程要么获得锁,执行相应的代码,要么无法获得锁处于等待状态,对于锁的处理不灵活。而 Lock 提供了多种基于锁的处理机制,比如:

// 获取一个锁,如果锁当前被其他线程获得,当前的线程将被休眠。
void lock()

// 尝试获取一个锁,如果当前锁被其他线程持有,则返回 false,不会使当前线程休眠。
boolean tryLock()

// 如果获取了锁定立即返回 true,如果别的线程正持有锁,会等待参数给定的时间,
// 在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false。
boolean tryLock(long timeout,TimeUnit unit)

// 如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,或者当前线程被别的线程中断。
void lockInterruptibly()

例如下面的操作使用 synchronized 对 count 操作进行加锁

public class Counter {
private int count;

public synchronized void add(int n) {
count += n;
}
}

使用 ReentrantLock 进行替换

public class Counter {
private final Lock lock = new ReentrantLock();
private int count;

public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}

所以 lock 比 synchronized 提供了更细的粒度、更灵活的控制。

885859-20190428143036791-1293316018.png

Lock 和 ReadWriteLock 是两大锁的根接口,Lock 代表实现类是 ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是 ReentrantReadWriteLock。

实现类作用
ReentrantLock一个可重入的互斥锁,为lock接口的主要实现。
ReentrantReadWriteLockReadWriteLock、ReadWriteLock 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。
Semaphore一个计数信号量。
Condition锁的关联条件,目的是允许线程获取锁并且查看等待的某一个条件是否满足。
CyclicBarrier一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点。

lock 方法

获取一个锁,如果锁当前被其他线程获得,当前的线程将被休眠,即使调用 B.interrupt() 也不能中断,除非线程 A 调用 LOCK.unlock() 释放锁。

Lock lock = new ReentrantLock();

lock.lock();
try{
// 处理任务
}finally{
lock.unlock(); //释放锁
}

lock.unlock(); 是被放入 finally 代码块里的,这是为了保证出现异常时,锁依然能被释放掉,避免死锁的产生。且 lock.lock(); 一定要放在外面,因为如果写在里面就算 lock.lock(); 加锁失败,那 finally 里面的 lock.unlock(); 也一定会被执行

lockInterruptibly

如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,或者当前线程被别的线程中断,但当调用 B.interrupt() 会被中断等待,并抛出 InterruptedException 异常

public void lockInterruptibly(Lock lock){
try {
lock.lockInterruptibly();
try{
System.out.println("操作资源")
}finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}

lock 与 lockInterruptibly 比较区别在于:

  • lock 优先考虑获取锁,待获取锁成功后,才响应中断。
  • lockInterruptibly 优先考虑响应中断,而不是响应锁的普通获取或重入获取。

lockInterruptibly 允许在等待时由其它线程调用等待线程的 interrupt() 方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个 InterruptedException。

lock 方法不允许 interrupt() 中断,即使检测到 Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为 interrupted 状态,然后再中断线程。

示例代码:

public class TTTT {  
public static void main(String[] args){

Thread i1 = new Thread(new RunIt3());
Thread i2 = new Thread(new RunIt3());
i1.start();
i2.start();
i2.interrupt();
}

}

class RunIt3 implements Runnable{

private static Lock lock = new ReentrantLock();
public void run(){
try{
//---------------------------------a
lock.lock();
//lock.lockInterruptibly();


System.out.println(Thread.currentThread().getName() + " running");
TimeUnit.SECONDS.sleep(20);
lock.unlock();
System.out.println(Thread.currentThread().getName() + " finished");
}
catch (InterruptedException e){
System.out.println(Thread.currentThread().getName() + " interrupted");

}

}
}

如果使用的是 lock.lock() 结果:

Thread-0 running 
(这里休眠了20s)
Thread-0 finished
Thread-1 running
Thread-1 interrupted

如果使用的是 lock.lockInterruptibly() 结果:

Thread-0 running 
Thread-1 interrupted
(这里休眠了20s)
Thread-0 finished

tryLock

它有个重构方法

// 该处不会等待,获取不到锁并直接返回 false,去执行下面的逻辑。
LOCK.tryLock()

// 该处会在 10 秒时间内处于等待中,但当调用 B.interrupt() 会被中断等待,并抛出 InterruptedException。
// 10 秒时间内如果线程A 释放锁,会获取到锁并返回 true,否则 10 秒过后会获取不到锁并返回 false,去执行下面的逻辑。
LOCK.tryLock(10, TimeUnit.SECONDS)

使用例:注意,当那个任务一定要执行

Lock lock = new ReentrantLock();
if(lock.tryLock()) {
try{
//处理任务
}catch(Exception ex){

}finally{
lock.unlock(); //释放锁
}
}else {
//如果不能获取锁,则直接做其他事情
}

使用例2

public class Temp {

public static void main(String[] args) throws InterruptedException {
// 加个 CountDownLatch等待所有线程执行完成
CountDownLatch countDownLatch = new CountDownLatch(3);
Counter counter = new Counter();

// 创建三个线程
for (int i = 0; i < 3; i++) {
new Thread(() -> {
counter.add(1);
countDownLatch.countDown();
}).start();
}

countDownLatch.await();
System.out.println(counter.count);
}

static class Counter {
private final Lock lock = new ReentrantLock();
private int count;

public void add(int n) {
// 这里使用个死循环用来重复尝试获取锁
for (; ; ) {
// 使用了 tryLock 就无需再用 lock.lock()
if (lock.tryLock()) {
try {
for (int j = 0; j < 1000 * 100000; j++) {
count += n;
}
} finally {
lock.unlock();
}
break;

// 取不到锁就打印一句话
} else {
System.out.println("还没拿到锁");
}
}
}
}
}

ReadWriteLock 接口(读锁、写锁接口)

ReadWriteLock 接口是一种很经典的共享锁和排他锁实现

ReadWriteLock 接口允许一次读取多个线程,但一次只能写入一个线程。

  • 读锁 - 如果没有线程锁定 ReadWriteLock 进行写入,则多线程可以访问读锁。
  • 写锁 - 如果没有线程正在读或写,那么一个线程可以访问写锁。

注意:写锁必须等所有的读锁用完后才可以创建

下面两个方法就是这个接口最常用的方法

// 返回用于读的锁。
public Lock readLock()

// 返回用于写的锁。
public Lock writeLock()

如下例子

public class Temp {
private static final ReadWriteLock lock = new ReentrantReadWriteLock(true);

// 避免在线程里面异常处理太臃肿,这里直接提取出一个方法
static void sleep(int time) {
try {
TimeUnit.MILLISECONDS.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {

// 创建多个只读线程,模拟它们同时读数据
for (int i = 0; i < 3; i++) {
int finalI = i;
new Thread(()-> {
// 创建个共享锁
lock.readLock().lock();
String name = "Reader" + finalI;

try {
System.out.println(name + " 线程睡眠 5秒");
sleep(5000);
}finally {
System.out.println(name + " 线程解锁了");
lock.readLock().unlock();
}
}, "Reader" + i).start();
}

// ---------------------------------------------------------------
sleep(10); // 确保是上面的读锁先创建的

// 创建写线程A
new Thread(()-> {
// 创建个独占锁
lock.writeLock().lock();

try {
System.out.println("WriterA 线程睡眠 2秒");
sleep(2000);
}finally {
System.out.println("WriterA 线程解锁了");
lock.writeLock().unlock();
}
}, "WriterA").start();

// ---------------------------------------------------------------

// 创建写线程B
new Thread(()-> {
// 创建个独占锁
lock.writeLock().lock();

try {
System.out.println("WriterB 线程睡眠 2秒");
sleep(2000);
}finally {
System.out.println("WriterB 线程解锁了");
lock.writeLock().unlock();
}
}, "WriterB").start();
}
}

输出的结果为:可以看到,写锁得等读锁都执行完成后才能创建

Reader1 线程睡眠 5秒
Reader0 线程睡眠 5秒
Reader2 线程睡眠 5秒
Reader0 线程解锁了
Reader1 线程解锁了
Reader2 线程解锁了
WriterA 线程睡眠 2秒
WriterA 线程解锁了
WriterB 线程睡眠 2秒
WriterB 线程解锁了

Lock 接口的主要实现类 ReentrantLock 锁

  • ReentrantLock 继承自 Lock 接口
  • ReentrantLock 和 synchronized 一样都是悲观锁
  • ReentrantLock 也是可重入锁,它和 synchronized 一样,一个线程可以多次获取同一个锁。
  • ReentrantLock 它是基于 AQS(AbstractQueuedSynchronizer)来实现的。

ReentrantLock 分为公平锁和非公平锁,可以通过构造方法来指定具体类型:

// 默认创建非公平锁
ReentrantLock()

// 是否创建一个公平锁创建
ReentrantLock(boolean fair)

默认一般使用非公平锁,它的效率和吞吐量都比公平锁高的多。由于公平锁需要关心队列的情况,得按照队列里的先后顺序来获取锁(会造成大量的线程上下文切换),而非公平锁则没有这个限制。

所以也就能解释非公平锁的效率会被公平锁更高。

使用例

例如下面的操作使用 synchronized 对 count 操作进行加锁

public class Counter {
private int count;

public synchronized void add(int n) {
count += n;
}
}

使用 ReentrantLock 进行替换

public class Counter {
private final Lock lock = new ReentrantLock();
private int count;

public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}

synchronized 和 ReentrantLock 的区别

两者都是可重入锁

“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock()unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的

ReentrantLock 比 synchronized 增加了一些高级功能

相比 synchronized,ReentrantLock 增加了一些高级功能。主要来说主要有三点:

等待可中断:ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

可实现公平锁:ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。

可实现选择性通知(锁可以绑定多个条件):synchronized 关键字与 wait()notify()/notifyAll() 方法相结合可以实现等待/通知机制。ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用 notify()/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现 “选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个 Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而 Condition 实例的 signalAll() 方法 只会唤醒注册在该 Condition 实例中的所有等待线程。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。不过性能已不是选择标准

ReentrantReadWriteLock 锁

ReentrantReadWriteLock 类,顾名思义,是一种读写锁,它是 ReadWriteLock 接口的直接实现,该类在内部实现了具体独占锁特点的写锁,以及具有共享锁特点的读锁,和 ReentrantLock 一样,ReentrantReadWriteLock 类也是通过定义内部类实现 AQS 框架的 API 来实现独占/共享的功能。

ReentrantReadWriteLock 是 ReentrantLock 的另一种实现方式,前面说过了 ReentrantLock 是一种排他锁,同一时间只允许一个线程访问,而 ReentrantReadWriteLock 允许多个线程同时访问,但不允许 写线程和读线程写线程和写线程 同时访问。相对于排他锁,提高了并发性。

在实际应用中,绝大部分情况对共享数据(如缓存)的访问都是读操作多于写操作的,这时 ReentrantReadWriteLock 能够提供比排他锁更好的并发性和吞吐量

Reference

参考资料 2020最新Java并发进阶常见面试题总结 参考资料 使用ReentrantLock